跳到主要内容

Java 包装类相关的问题

自动装箱的原理

自动装箱就是 Java 自动将原始类型值转换成对应的对象,比如将 int 的变量转换成 Integer 对象,这个过程叫做装箱,反之将 Integer 对象转换成 int 类型值,这个过程叫做拆箱。

因为这里的装箱和拆箱是自动进行的非人为转换,所以就称作为自动装箱和拆箱。

原始类型 byte, short, char, int, long, float, double 和 boolean 对应的封装类为 Byte, Short, Character, Integer, Long, Float, Double, Boolean。

public class Main {
public static void main(String[] args) {

Integer i = 10;
int n = i;
}
}

反编译 class 文件之后得到如下内容:

从反编译得到的字节码内容可以看出,在装箱的时候自动调用的是 Integer 的 valueOf(int) 方法。

而在拆箱的时候自动调用的是 Integer 的 intValue 方法。

Integer a = 1000;
int b = 1000;
System.out.println("return: " + (a == b)); // 输出为:return: true

注意:当 "==" 运算符的两个操作数都是 包装器类型的引用,则是比较指向的是否是同一个对象,而如果其中有一个操作数是表达式(即包含算术运算)则比较的是数值(即会触发自动拆箱的过程)

所以当两个都是 Integer 类型时则不会发生拆箱,而是直接比对指针地址

Integer a = 1000;
Integer b = 1000;
System.out.println("return: " + (a == b)); // 输出为:return: false

String 直接赋值与 new String的区别

虽然它不是基本类型,但是也经常遇到坑,这里拿来一起说了

在研究 String 直接赋值与 new String 的区别之前我们需要先了解 Java 中的字符串常量池的概念

String 类是我们平常项目中使用频率非常高的一种对象类型,jvm 为了提升性能和减少内存开销,避免字符的重复创建,其维护了一块特殊的内存空间,即字符串池,当需要使用字符串时,先去字符串池中查看该字符串是否已经存在(通过比对 Hash),如果存在,则可以直接使用,如果不存在,初始化,并将该字符串放入字符创常量池中。

String str = "abc"; 可能创建一个或者不创建对象,如果 "abc" 在字符串池中不存在,会在 Java 字符串池中创建一个 String 对象(abc),然后 str 指向这个内存地址,无论以后用这种方式创建多少个值为 abc 的字符串对象,始终只有一个内存地址被分配。

注意:== 判断的是对象的内存地址,而 equals 判断的是对象内容。通过以下代码测试:

String str = "abc";
String str1 = "abc";
String str2 = "abc";
System.out.println(str==str1);//true
System.out.println(str==str2);//true

也就是 str、str1、str2 都是指向同一个内存地址。

String str = new String("abc"); 至少会创建一个对象,也有可能创建两个。因为用到 new 关键字,肯定会在堆中创建一个 String 对象,如果字符池中已经存在 "abc",则不会在字符串池中创建一个 String 对象,如果不存在,则会在字符串常量池中也创建一个对象。

String str = new String("abc");
String str1 = new String("abc");
String str2 = new String("abc");
System.out.println(str==str1);//false
System.out.println(str==str2);//false

可以看出来,str、str1、str2 指向的是不同的内存地址

项目中除了直接使用 = 赋值,也会用到字符串拼接,字符串拼接又分为变量拼接和已知字符串拼接(这里细节看 JVM 字符常量池那里)

String str = "abc";//在常量池中创建abc
String str1 = "abcd";//在常量池中创建abcd

//拼接字符串,此时会在堆中新建一个abcd的对象,因为str2编译之前是未知的
String str2 = str + "d";

//拼接之后str3还是abcd,所以还是会指向字符串常量池的内存地址
String str3 = "abc" + "d";

System.out.println(str1 == str2);//false
System.out.println(str1 == str3);//true

String 类型的拆箱

String str = new String("hello");
System.out.println(str == "hello");

返回的是 false,因为 String 类型不是基本类型,所以不存在拆箱这一操作

Integer 包装类如何比较它们

在开发中我们经常会使用包装类(例如 Boolean, Double, 以及 Integer 等等)。

String one = "1";
Boolean b1 = Boolean.valueOf(one); // line n1

Integer i1 = new Integer(one);
Integer i2 = 1;
if (b1) {
System.out.print(i1 == i2);
}

执行结果是什么,请选择:

  • A、 抛出运行时异常
  • B、 true
  • C、 false
  • D、 无任何输出

这个问题考察原生数据的包装类(primitive wrapper),主要是 Boolean 类比较生僻的 valueOf 工厂方法。

包装类主要提供了三种获取对象实例的方法:

1、每个包装类都有名为 valueOf 的静态工厂方法。

2、如果语义很清晰,在代码中将原生数据类型赋值给包装类的变量,则会发生自动装箱 (autoboxing)。 自动装箱只是语法上的简写,它允许编译器 (javac) 自动调用 valueOf 方法,目的是为了编码更简洁。

3、第三种方法是使用构造器,也就是通过 new 关键字来调用构造函数。 实际上,在 Java 9 中已经不推荐使用第三种方法。

为什么不应该使用 new

在 Java 中,只要使用 new 关键字调用构造函数,只会发生两种情况: 要么成功创建指定类型的新对象并返回,要么就抛异常。

这实际上是一个限制,如今一般是推荐使用工厂方法,因为工厂方法除了达成构造函数的效果之外,还会有一些优化。

工厂方法的有些功能是用构造函数实现不了的:比如返回与请求参数相匹配的已缓存的实例对象(例如字符常量池)

这种行为类似于在编码中直接使用 "XXX" 这种字面量表示方式, 而不是 new String("XXX")

因为 Integer 包装器是不可变的,表示相同数值的两个 Integer 对象一般是可以互换的。

因此,创建多个表示相同值的对象实例会浪费内存。

很多情况下,工厂方法返回的两个对象允许使用 == 来比较, 而不必每次都写成 equals(Object o) 这种方式。

对于 Integer 类来说,一般只缓存了 -128 到 +127 范围内的值。(具体看下面缓存那节)

工厂方法的返回值

先来看下,使用工厂方法的两个好处:

  • 如果有多个工厂方法,则每个方法都可以使用不同的名称,因为名称不同,也就可以使用相同的入参声明。
  • 对于构造函数而言,因为必须参数类型不同才能形成重载,也就不可能根据同样的参数构造不同的对象。

在 Java 中用 new 调用构造函数只能返回固定类型的对象。

而用工厂方法则可以返回兼容的各种类型对象实例(例如接口的实现类,而且这是一种隐藏实现细节的绝佳方法)。

回到这个问题,最关键的地方在于,我们使用 Boolean.valueOf(...) 方法时,只会得到两个常量对象: Boolean.TRUEBoolean.FALSE

这两个对象可以被重复利用,不会浪费多余的内存。 如果使用 new 调用显然是不可能的

大部分包装类的工厂方法,如果传入了 null 参数,或者字符串参数不符合目标值的表现形式就会抛出异常,例如,Integer.valueOf("six") 就会抛异常。

java.lang.Boolean 类的工厂方法是个特例, 内部实现判断的是非空(null)并且等于 “true”(忽略大小写)。

String one = "1";
Boolean b1 = Boolean.valueOf(one); // 这里实际上是 False

内部实现如下所示:

public static boolean parseBoolean(String s) {
return ((s != null) && s.equalsIgnoreCase("true"));
}
  • 如果满足这两个条件则返回 Boolean.TRUE
  • 否则直接返回 Boolean.FALSE

这意味着: 如果传入 null 或者无意义的字符串,则会返回 Boolean.FALSE,并不会抛出异常。

基于这点,我们可以确定 n1 行那里不会抛出异常,而是返回 Boolean.FALSE,被赋值给变量 b1。

因此,可以确定 选项A 不正确。

两种形式的比较

  • 第一种是 == 运算符,是 Java 语法的一部分。
  • 第二种是 equals(Object o) 方法,本质上是一个 API。

每个对象都可以使用 equals(Object o) 方法,因为这个方法是在 java.lang.Object 类中定义的。

== 运算符比较两个表达式的值。

听起来很简单,但是表达式的值可能有两种不同的类型。这两种类型使用 == 的结果可能会不同。

表达式主要有两种类型:

  • 原生数据类型 / 基本数据类型 (primitive,共8种: boolean, byte, short, char, int, long, float, double)
  • 引用类型(reference),引用类似于指针,表示内存中某个对象的地址值(可以认为是一个偏移量数值)。

如果表达式是原生数据类型,则表达式的值很直观。 例如,如果 int 表达式的值为 32,则该表达式的值就是 32 的二进制表示形式。

但问题是,如果变量是引用类型呢?(例如,Integer 类型)

它所引用对象内部的值为32,这个引用的值 并不是 32。而是一个神秘的数字(引用地址),通过这个引用地址,JVM 可以找到对应的 Integer 对象

也就是说,对于引用类型(即除了 8 种原生数据类型之外的所有类型),== 表达式判断的是这两个引用的内存地址值是否相等,即判断它们是否引用了同一个对象。

最重要的是,即使两个 Integer 对象里面的值都是 32,但如果它们是不同的对象, 那么它们的引用地址也就不同,使用 == 比较会返回 false。

如下所示:

Integer v1 = new Integer("1");
Integer v2 = new Integer("1");
System.out.print(v1 == v2);

这里的输出肯定是 false

前面提到过,new 关键字的任何调用,要么产生一个新对象,要么抛异常。这意味着 v2 和 v1 引用了不同的对象,== 操作的结果为 false

换一种方式,如果有以下代码:

Integer v1 = new Integer("1");
Integer v2 = 1;
System.out.print(v1 == v2);

这与面试题中的代码很像,一个使用构造函数, 一个使用自动装箱,可以肯定这也会输出 false。因为构造函数创建的对象必定是唯一的新对象,因此,不可能 == 自动装箱为工厂方法返回的对象。

Integer 的缓存 “坑”

而不可变对象的工厂方法一般都会有特殊处理,只要在一个范围内,并且参数相等,就返回同一个(缓存的)对象。

Integer 类的 API文档中,对 valueOf(int) 方法有如下说明:

“此方法将始终缓存 [-128 ~ 127] 范围内的值, 可能还会缓存这个范围之外的其他值。”

@HotSpotIntrinsicCandidate
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

所以对以下代码:

Integer v1 = Integer.valueOf(1);
Integer v2 = Integer.valueOf(1);
System.out.print(v1 == v2);

上面这段代码肯定会输出 true

虽然只在 valueOf(int)valueOf(String) 方法的文档说明中提到了这个缓存保证。

但在实际的实现中, 其他包装类也表现出相同的缓存行为。

上面也说了,它只缓存 [-128 ~ 127] 范围内的值,大于这个值则是不同的

Integer a = Integer.valueOf(1000);
Integer b = Integer.valueOf(1000);
System.out.println("return: " + (a == b)); // 输出为:return: false

同样,直接赋值给包装类实际还是调用了 valueOf 方法,因此

Integer a = 1000;
Integer b = 1000;
System.out.println("return: " + (a == b)); // 输出为:return: false

但是如果和 int 类型比较时则会发生自动拆箱

Integer a = 1000;
int b = 1000;
System.out.println("return: " + (a == b)); // 输出为:return: true

注意:当 "==" 运算符的两个操作数都是 包装器类型的引用,则是比较指向的是否是同一个对象,而如果其中有一个操作数是表达式(即包含算术运算)则比较的是数值(即会触发自动拆箱的过程),所以包装类尽量使用 equals 方法来判断是否相等

补充:为什么 Java 只有值传递

首先,我们回顾一下在程序设计语言中有关将参数传递给方法(或函数)的一些专业术语。

  • 按值调用(call by value)表示方法接收的是调用者提供的值,
  • 按引用调用(call by reference)表示方法接收的是调用者提供的变量地址。

一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。它用来描述各种程序设计语言(不只是 Java)中方法参数传递方式。

Java 程序设计语言总是采用按值调用。也就是说,方法得到的是所有参数值的一个拷贝,也就是说,方法不能修改传递给它的任何参数变量的内容。

Reference

参考资料 Java坑人面试题系列: 包装类(中级难度) 参考资料 深入剖析Java中的装箱和拆箱